Android
Memory
Leaks
Objects that should be dead, kept alive by forgotten references. A complete guide to understanding, detecting, and eliminating every category of memory leak in Android.
Android Memory Model
Android runs each app in a sandboxed Dalvik/ART process with a fixed heap limit. This limit varies by device — from 32MB on older low-end devices to 512MB+ on modern flagships. Cross this limit and the OS throws an OutOfMemoryError, immediately crashing your app.
The heap is divided into generations. The Young generation (Eden + Survivor spaces) holds short-lived objects. Most objects die young — collected quickly and cheaply. The Old generation holds long-lived objects. GC here is expensive and pauses the world. Memory leaks accumulate in the Old generation.
The heap limit is per-process, not per-app. If you use android:process to run components in separate processes, each process gets its own heap limit. But this also means they can't share memory directly — another source of overhead to consider.
GC, Roots & Reachability
The garbage collector works by tracing a graph of object references starting from GC Roots. Any object reachable from a GC root is considered alive and will not be collected. Memory leaks occur when an object you consider logically "dead" is still reachable from a GC root through a chain of references.
Definition: A memory leak is an object that is no longer needed by your application logic, but is still reachable from a GC root. The GC cannot collect it. It accumulates. Eventually your heap fills up and the app crashes with OutOfMemoryError.
GC Roots in Android
The leak chain always has the same structure: GC Root → long-lived object → leaked object. For example: static field → Singleton → Activity. The Activity is done. The user rotated the screen. But the Singleton holds a reference. The GC traces from the static field, reaches the Singleton, reaches the Activity — and marks it alive. The Activity's entire view hierarchy stays in memory. 50MB gone per rotation.
Static Reference Leaks
Static fields live for the lifetime of the process. Anything you store in a static field — or in a singleton backed by a static field — must never hold a reference to an Activity, Fragment, View, or Context. These UI objects have lifecycle-bound lifetimes. Static fields do not.
// ✗ Static Activity reference — rotates once = 1 leaked Activity + full view hierarchy object AnalyticsManager { var currentActivity: Activity? = null // LEAKS on rotation } // ✗ Static View reference — View holds Context (Activity) companion object { private var cachedView: View? = null // entire window hierarchy kept alive } // ✗ Singleton holding Application context used as Activity context class ImageLoader private constructor(val context: Context) { companion object { fun init(context: Context) = ImageLoader(context) // pass Activity accidentally } }
// ✓ Never store Activity in static field. Use callbacks or WeakReference. object AnalyticsManager { private var activityRef: WeakReference<Activity>? = null fun attach(a: Activity) { activityRef = WeakReference(a) } fun detach() { activityRef = null } // call in onDestroy } // ✓ Singletons should use Application context, never Activity context class ImageLoader private constructor(val context: Context) { companion object { fun init(context: Context) = ImageLoader(context.applicationContext) } }
Inner Class & Anonymous Class Leaks
Non-static inner classes and anonymous classes hold an implicit reference to their enclosing outer class. If the inner class outlives the outer class — by being posted to a Handler, passed to a background thread, or stored in a callback — the outer class (often an Activity) is leaked.
class SplashActivity : AppCompatActivity() { // ✗ Non-static inner class — holds implicit ref to SplashActivity inner class SplashHandler : Handler(Looper.getMainLooper()) { override fun handleMessage(msg: Message) { navigateToMain() } } private val handler = SplashHandler() // queued messages keep Activity alive // ✗ Anonymous Runnable — captures 'this' (Activity) private fun startTimer() { Handler.postDelayed({ doSomething() }, 5000) // lambda captures Activity } }
// ✓ Static inner class + WeakReference to outer class SplashActivity : AppCompatActivity() { private class SplashHandler(activity: SplashActivity) : Handler(Looper.getMainLooper()) { private val ref = WeakReference(activity) override fun handleMessage(msg: Message) { ref.get()?.navigateToMain() // safe: Activity may be null } } // ✓ Modern: use lifecycleScope instead of Handler entirely private fun startTimer() { lifecycleScope.launch { delay(5000) navigateToMain() } // cancelled automatically in onDestroy } }
Modern advice: Avoid Handler entirely for delayed work in Activities and Fragments. Use lifecycleScope.launch { delay(ms); doWork() } — it's automatically cancelled when the lifecycle is destroyed, no manual cleanup needed.
Context Leaks
Context is the single most leaked object type in Android. There are two fundamentally different Context types and choosing the wrong one in a long-lived object causes leaks every time.
// ✗ ViewModel storing Activity context class BadViewModel(val context: Context) : ViewModel() { // ViewModel outlives Activity on rotation — Activity leaked! fun loadImage() = Glide.with(context).load(url) // context = Activity } // ✗ Custom View caching a Context in a static field class ThemeHelper { companion object { var ctx: Context? = null // set to Activity, never cleared } } // ✗ Dialog not dismissed — holds Activity window token class MyActivity : AppCompatActivity() { private var dialog: AlertDialog? = null override fun onDestroy() { // dialog showing when Activity dies super.onDestroy() // dialog.dismiss() not called! } }
// ✓ ViewModel using Application context via AndroidViewModel or Hilt @HiltViewModel class GoodViewModel @Inject constructor( @ApplicationContext private val context: Context // Application context, safe ) : ViewModel() // ✓ Always dismiss dialogs in onDestroy override fun onDestroy() { dialog?.dismiss() dialog = null super.onDestroy() }
Listener & Callback Leaks
Registering a listener creates a reference from the system (or a long-lived object) to your Activity/Fragment. If you forget to unregister, that reference keeps your UI alive long after it should be gone. This is extremely common with broadcast receivers, location managers, sensor managers, and custom event buses.
class MainActivity : AppCompatActivity() { // ✗ BroadcastReceiver registered, never unregistered private val receiver = object : BroadcastReceiver() { override fun onReceive(ctx: Context, intent: Intent) { handleBroadcast() } } override fun onResume() { super.onResume() registerReceiver(receiver, IntentFilter("ACTION")) // registered... } // ... but onPause/onDestroy never calls unregisterReceiver() // ✗ LocationManager listener — callback holds Activity reference override fun onStart() { locationManager.requestLocationUpdates(provider, 0, 0f, locationListener) } // Missing: locationManager.removeUpdates(locationListener) in onStop() }
class MainActivity : AppCompatActivity() { // ✓ Symmetric: register in onStart, unregister in onStop override fun onStart() { super.onStart() registerReceiver(receiver, filter) locationManager.requestLocationUpdates(provider, 0, 0f, locationListener) } override fun onStop() { unregisterReceiver(receiver) locationManager.removeUpdates(locationListener) super.onStop() } // ✓ Modern: use Lifecycle-aware components — auto-unregisters override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(LocationObserver(locationManager)) // LocationObserver implements DefaultLifecycleObserver // registers in onStart(), removes in onStop() automatically } }
Coroutine & Scope Leaks
Coroutines running in the wrong scope can leak entire coroutine contexts, suspend functions waiting on callbacks, and all objects captured in their closure. This is the most modern and most underestimated category of leak.
class DataFragment : Fragment() { // ✗ GlobalScope — never cancelled, outlives Fragment fun loadData() { GlobalScope.launch { val data = repo.fetchData() // Fragment destroyed mid-flight? binding.textView.text = data.title // NPE or view reference on dead Fragment } } // ✗ Custom CoroutineScope not cancelled private val scope = CoroutineScope(Dispatchers.Main) // Missing: scope.cancel() in onDestroyView/onDestroy // ✗ Collecting flow with lifecycleScope without repeatOnLifecycle override fun onViewCreated(view: View, savedState: Bundle?) { lifecycleScope.launch { viewModel.flow.collect { updateUI(it) } // collects even in background! } } }
class DataFragment : Fragment() { // ✓ viewModelScope — cancelled in onCleared() automatically // ✓ lifecycleScope — cancelled in onDestroy() automatically // ✓ viewLifecycleOwner.lifecycleScope — cancelled in onDestroyView() override fun onViewCreated(view: View, savedState: Bundle?) { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { render(it) } // stops collecting when STOPPED, resumes when STARTED // cancelled entirely in onDestroyView } } } // ✓ Custom scope: cancel it yourself private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) override fun onDestroyView() { scope.cancel() super.onDestroyView() } }
Bitmap & Drawable Leaks
Bitmaps are the largest objects in most Android apps. A single full-screen 2048×2048 image at ARGB_8888 consumes 16MB. Multiply that by cached thumbnails, background images, and recycled bitmaps improperly referenced — and you have the #1 source of OOM crashes.
// ✗ Custom View holding Bitmap without recycle class AvatarView(context: Context) : View(context) { private var avatar: Bitmap? = null fun setImage(bmp: Bitmap) { avatar = bmp // old bitmap never recycled if replaced invalidate() } // No onDetachedFromWindow() cleanup — bitmap lives forever } // ✗ Large Bitmap decoded on main thread without inSampleSize val full = BitmapFactory.decodeResource(resources, R.drawable.hero) // 40MB raw
// ✓ Always use an image loading library (Coil, Glide, Picasso) // They handle: caching, sampling, recycle, lifecycle-awareness Coil: binding.imageView.load(url) { crossfade(true) size(ViewSizeResolver(binding.imageView)) // auto-samples to view size } // lifecycle-aware: cancels request when Fragment is destroyed // ✓ If manual, clean up in onDetachedFromWindow class AvatarView(context: Context) : View(context) { private var avatar: Bitmap? = null fun setImage(bmp: Bitmap) { avatar?.recycle() // recycle old before replacing avatar = bmp; invalidate() } override fun onDetachedFromWindow() { avatar?.recycle(); avatar = null // clean up when view removed super.onDetachedFromWindow() } }
Compose-specific Leaks
Jetpack Compose has a different memory model than Views, but it introduces its own leak patterns — particularly around effects, state, and the boundary between Compose and the traditional View system.
// ✗ DisposableEffect missing onDispose — observer never removed @Composable fun BadObserver(viewModel: MyViewModel) { DisposableEffect(viewModel) { viewModel.addListener(myListener) onDispose { } // empty onDispose — listener never removed! } } // ✗ Capturing MutableState in a non-composable lambda that escapes @Composable fun BadState() { var count by remember { mutableStateOf(0) } SomeGlobalObject.listener = { count++ } // state captured in global callback // Composition leaves → count state kept alive by global listener } // ✗ collectAsState() instead of collectAsStateWithLifecycle() val state = viewModel.flow.collectAsState() // keeps collecting in background
// ✓ DisposableEffect with proper cleanup @Composable fun GoodObserver(viewModel: MyViewModel) { DisposableEffect(viewModel) { viewModel.addListener(myListener) onDispose { viewModel.removeListener(myListener) } // always paired } } // ✓ Use collectAsStateWithLifecycle — stops when UI goes background val state = viewModel.flow.collectAsStateWithLifecycle() // ✓ Never capture Compose state in non-composable scopes @Composable fun GoodState(onEvent: () -> Unit) { // pass callback up, not state out SomeWidget(onClick = onEvent) // Compose UI never leaks into global objects }
Heap Simulator
Watch your heap grow as you create leaks, observe GC events, and see what happens when the heap is exhausted. Each button simulates a real-world leak scenario.
Detection Tools
Finding memory leaks requires both automated tools and manual heap analysis. Here's the complete toolkit, ordered from easiest to most powerful.
StrictMode.setVmPolicy() with detectLeakedClosableObjects(), detectLeakedSqlLiteObjects(). Crashes or logs when you forget to close streams, cursors, or SQLite connections — a common but boring leak category.LeakCanary Internals
LeakCanary is the most important tool in an Android developer's leak-detection arsenal. Understanding how it works helps you interpret its output and configure it for advanced use cases.
// build.gradle.kts — add to debug dependencies only debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14") // That's it. No Application.onCreate() code. ContentProvider auto-installs it. // ── What LeakCanary does automatically ── // 1. Hooks ActivityLifecycleCallbacks — watches every Activity // 2. After onDestroy(), waits 5 seconds // 3. Creates WeakReference to the Activity // 4. Forces GC, checks if WeakReference was cleared // 5. If not cleared → suspected leak → dumps HPROF // 6. Analyzes heap in background → finds shortest GC root path // 7. Shows notification with full leak trace // ── Custom watched objects (beyond Activities) ── class MyService : Service() { override fun onDestroy() { super.onDestroy() AppWatcher.objectWatcher.expectWeaklyReachable(this, "MyService destroyed") } } // ── Reading a LeakCanary trace ── // ┬─────────────────────────────────────────── // │ GC Root: static field AnalyticsManager.INSTANCE // │ ↓ // │ AnalyticsManager.context ↓ (LEAK SUSPECT) // │ ↓ // ╰→ MainActivity (5 instances, 48 MB retained) // ┴───────────────────────────────────────────
LeakCanary threshold: The AppWatcher waits 5 seconds after onDestroy before checking. If the object was collected within those 5 seconds, no leak is reported. Only objects that survive a GC cycle are reported as actual leaks — very few false positives.
Reading leak traces
LeakCanary outputs a reference chain from the GC root to the leaked object. The leak suspect is the reference that shouldn't exist. Fix that reference (clear it in the right lifecycle callback, use WeakReference, or use Application context) and the leak disappears.
Retained size vs shallow size: When LeakCanary reports "48 MB retained", that's the total memory that would be freed if the leaked object were collected — including all objects only reachable through it. The shallow size is just the object itself. Always look at retained size to understand the real impact.
Prevention Checklist
Apply these rules consistently and you will eliminate 95% of memory leaks before they reach production.
Context rules
@ApplicationContext with Hilt — it prevents accidentally injecting Activity context into long-lived componentsonDestroy() — they hold window tokens that can outlive the ActivityLifecycle rules
onDestroyView() — the binding holds a reference to the entire view hierarchyviewLifecycleOwner (not this) when observing LiveData in FragmentsonStart() removed in onStop(). Every receiver registered in onResume() unregistered in onPause()onDestroyView for view-related, onDestroy for component-related)Coroutine rules
GlobalScope — use viewModelScope, lifecycleScope, or a scoped CoroutineScope that you cancelrepeatOnLifecycle(STARTED) when collecting flows in Fragments — stops collection in background, prevents resource wastecollectAsStateWithLifecycle() in Compose — never plain collectAsState() for long-lived flowsInner class rules
static (or top-level in Kotlin) — non-static inner classes capture this implicitlyWeakReference when a long-lived object must reference a short-lived one — but prefer restructuring so this isn't necessaryHandler.postDelayed with lifecycleScope.launch { delay() } — the coroutine is automatically cancelledTooling rules
All leak types — quick reference
| Leak type | Severity | Root cause | Fix |
|---|---|---|---|
| Static Activity ref | CRITICAL | Static field or singleton holds Activity | Use ApplicationContext or WeakReference |
| Non-static inner class | CRITICAL | Implicit this capture | Make static / top-level, use WeakReference |
| Context in ViewModel | CRITICAL | ViewModel outlives Activity | Use @ApplicationContext |
| Unregistered listeners | HIGH | Register without matching unregister | Symmetric lifecycle calls |
| GlobalScope coroutines | HIGH | Coroutine never cancelled | Use viewModelScope / lifecycleScope |
| ViewBinding in Fragment | HIGH | Binding not nulled in onDestroyView | Set _binding = null in onDestroyView |
| Bitmap not recycled | HIGH | Large objects held after use | Use Coil/Glide, or recycle manually |
| Dialog not dismissed | HIGH | Window token held | dismiss() in onDestroy |
| Compose DisposableEffect | MEDIUM | Empty onDispose | Always pair setup with cleanup |
| collectAsState in Compose | MEDIUM | Collects in background | Use collectAsStateWithLifecycle |
| StrictMode violations | MEDIUM | Unclosed streams/cursors | Close in finally or use Closeable.use { } |